Skip to content

fix: serve public files in prod#766

Merged
james-elicx merged 6 commits intocloudflare:mainfrom
hyoban:4-3-public
Apr 3, 2026
Merged

fix: serve public files in prod#766
james-elicx merged 6 commits intocloudflare:mainfrom
hyoban:4-3-public

Conversation

@hyoban
Copy link
Copy Markdown
Contributor

@hyoban hyoban commented Apr 3, 2026

Currently, we cannot access static files in the public folder.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@766

commit: 3581158

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix: serve public files in prod

Nice contribution! The signal-based approach (RSC handler returns x-vinext-static-file header, prod server intercepts and serves the actual file) is a clean architecture that keeps the RSC entry runtime-agnostic while letting the Node.js prod server handle actual file I/O. The test coverage is good — both the simple case and the basePath case are tested.

I have a few concerns, mostly around the Cloudflare Workers gap and a potential security issue with the basePath check placement.

Cloudflare Workers gap

The x-vinext-static-file signal is only consumed by prod-server.ts (Node.js). The Cloudflare worker entries (app-router-entry.ts, deploy-generated workers) do not intercept this signal. On Cloudflare, this works by accident because Cloudflare's static asset layer serves files from dist/client/ before the worker runs. But:

  1. If not_found_handling is set to something other than "none", or if the static asset binding configuration changes, the signal Response (null body + header) would be returned to the client as-is.
  2. The PR description says "we cannot access static files in the public folder" but doesn't specify which runtime. If this is a Node.js-only fix, that should be documented. If Cloudflare is also affected, the worker entry needs a handler too.

This isn't necessarily a blocker (it may already work on CF), but it should be documented or addressed.

Summary of inline comments below

  1. Security concern: The basePath 404 guard may reject requests that arrive before the basePath check but need to reach middleware (e.g., health checks, load balancer probes at /). This matches Next.js behavior, but worth confirming.
  2. Cache invalidation: scanPublicFileRoutes uses a module-level cache keyed by publicDir path, but the cache is never invalidated. In dev mode with watch, new files added to public/ won't be reflected in __publicFiles.
  3. node_modules skip in walk: The node_modules skip in scanPublicFileRoutes is unusual — public/ shouldn't contain node_modules. If it does, something is wrong with the project setup.
  4. Minor: The _mwCtx.status fallback in __createStaticFileSignal uses 200, but static file responses should arguably use whatever status the static file server determines (could be 304 for conditional requests).

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

The review has been submitted. Here's a summary of the feedback I posted:

Overall: The signal-based architecture (x-vinext-static-file header from RSC handler, intercepted by prod-server) is clean and the test coverage is solid with both a basic public file test and a basePath test.

Key concerns raised:

  1. Cloudflare Workers gap — The x-vinext-static-file signal is only consumed by prod-server.ts. Cloudflare worker entries don't handle it. It works on CF because the static asset layer serves dist/client/ files before the worker runs, but this is implicit rather than explicit.

  2. BasePath 404 guard scope creep — The PR adds a basePath rejection guard that's a behavioral change beyond just "serve public files." It should be documented in the PR description.

  3. Cache invalidationscanPublicFileRoutes has a module-level cache that's never invalidated, which could cause stale results in long-running dev servers.

  4. Middleware status silently dropped — The signal carries _mwCtx.status but prod-server.ts ignores it entirely when serving the actual file, silently dropping any middleware status override.

  5. Minor items — The node_modules skip in the walk is unusual for public/, and symlinks aren't handled.

github run

@hyoban
Copy link
Copy Markdown
Contributor Author

hyoban commented Apr 3, 2026

comments resolved

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix: serve public files in prod

The signal-based architecture is solid — the RSC handler identifies public file requests and returns a lightweight signal response (x-vinext-static-file header), which is then resolved by the appropriate server layer (Node.js prod-server or Cloudflare worker). Good separation of concerns. Tests are thorough: unit tests for scanPublicFileRoutes, resolveStaticAssetSignal, tryServeStatic status override, plus integration tests for both plain and basePath scenarios.

I have one concrete bug (double resolution in deploy-generated workers) and a few smaller items.

Key issues

  1. Double resolveStaticAssetSignal call in deploy-generated workersapp-router-entry.ts resolves the signal when env.ASSETS exists, and then the deploy-generated worker in deploy.ts calls resolveStaticAssetSignal again on the already-resolved response. It's not a correctness bug (the header is stripped after the first call, so the second call returns null), but it means every static file request does an unnecessary ASSETS.fetch() → resolve → header-scan cycle, and it makes the code harder to reason about. Either app-router-entry.ts should not resolve when it knows a wrapping worker will do it, or deploy.ts should trust that app-router-entry.ts already resolved.

  2. statusOverride logic when asset fetch fails — In resolveStaticAssetSignal, statusOverride is only applied when assetResponse.ok. If ASSETS.fetch returns a 404, the signal's middleware status is silently dropped and the 404 is returned as-is. This is probably the correct behavior, but it's worth a comment explaining the intent.

Minor items

  1. The scanPublicFileRoutes symlink loop detection via visitedDirs is a nice touch.
  2. Test coverage for the basePath guard (!hasBasePath && !/__vinext/) is good — it's a meaningful behavioral change bundled into this PR.

fetchAsset: (path) =>
Promise.resolve(env.ASSETS!.fetch(new Request(new URL(path, request.url)))),
});
if (assetResponse) return assetResponse;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: double signal resolution with deploy-generated workers.

The deploy-generated worker entry (deploy.ts line ~502) calls handler.fetch(request, env, ctx) — which is this file. Since the deploy worker passes env (which has ASSETS), this block resolves the signal and returns a fully-formed asset response. Then back in the deploy worker, resolveStaticAssetSignal is called again on that response.

The second call is a no-op (header is stripped), but it still:

  • Scans headers looking for x-vinext-static-file
  • Allocates an unnecessary function call + buildHeaderRecord

More importantly, it's confusing — two layers both claim responsibility for the same signal.

Consider gating this on a condition that indicates no outer wrapper will handle it, or remove the signal handling from one of the two layers. The simplest fix: since app-router-entry.ts is the shared entry used by both direct wrangler usage and deploy-generated workers, and the deploy worker always wraps it, maybe this block should only run when there's no outer resolveStaticAssetSignal caller. One approach: only resolve here if there is no image optimization handler wrapping this (i.e., the user is using app-router-entry directly in wrangler.jsonc without a custom worker).

Alternatively, just remove the signal resolution from deploy.ts since app-router-entry.ts already handles it.

cancelResponseBody(signalResponse);
const assetResponse = await options.fetchAsset(assetPath);
const statusOverride =
assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent of this condition could use a comment. When assetResponse.ok is false (e.g., 404 from the asset layer), the middleware status is dropped and the asset's error status is returned directly. When the asset is found but middleware set a non-200 status (e.g., 403 via rewrite), the middleware status overrides.

This seems correct (don't override a real 404 with a middleware 403), but it's non-obvious. A one-liner would help:

Suggested change
assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined;
// Only override the asset status with the middleware status when the asset was
// successfully fetched — if the asset itself errored (404, 500), preserve that.
const statusOverride =
assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined;

Comment on lines 1443 to +1448
${
bp
? `
if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) {
return new Response("Not Found", { status: 404 });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basePath 404 guard is a meaningful behavioral change beyond "serve public files." Previously, requests without the basePath prefix could fall through to catch-all routes or middleware; now they get a hard 404.

This matches Next.js behavior and is probably correct, but it should be called out in the PR description since it changes observable behavior for any app with basePath configured. The /__vinext/ exception is good — internal endpoints need to bypass.

Worth noting: this also blocks middleware from seeing non-basePath requests. In Next.js, middleware's matcher config controls this, but here the 404 fires before middleware runs. This is consistent with how Next.js handles it (basePath rejection is early), but should be verified.

if (
(request.method === "GET" || request.method === "HEAD") &&
!pathname.endsWith(".rsc") &&
__publicFiles.has(cleanPathname)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the .rsc suffix check prevents RSC data fetches from being intercepted, which is correct. But there's no guard against POST requests to public file paths that happen to match (e.g., a form submission to /logo/logo.svg). The GET || HEAD check handles this — just confirming that's intentional and sufficient.

Also, cleanPathname here is post-middleware-rewrite, which means middleware can rewrite any path to serve a public file. This matches Next.js semantics (middleware rewrites are applied before filesystem route matching).

staticResponseHeaders,
response.status,
);
cancelResponseBody(response);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: cancelResponseBody is called after tryServeStatic regardless of success. This correctly cleans up the signal response's body (which is null anyway, but defensive).

One edge case: if tryServeStatic throws (e.g., filesystem error mid-stream after headers are sent), the response body of the signal is still cancelled, but res may be in an inconsistent state. The outer try/catch at line 1100 handles this with a 500 fallback, so it should be fine.

return handler.fetch(request, env, ctx);
const response = await handler.fetch(request, env, ctx);
return (
(await resolveStaticAssetSignal(response, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted on app-router-entry.ts, this resolveStaticAssetSignal call is redundant when app-router-entry.ts already resolves the signal (because env is passed through to handler.fetch). The signal header is stripped by the first resolution, so this second call always returns null.

If the intent is for the deploy worker to be the resolution point (since it knows about env.ASSETS), then app-router-entry.ts should not resolve when env.ASSETS is present. If the intent is for app-router-entry.ts to handle it (so direct wrangler usage works), then this block can be removed.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review posted. Here's a summary of the feedback:

Main issue: Double resolveStaticAssetSignal in deploy-generated workers. app-router-entry.ts resolves the x-vinext-static-file signal when env.ASSETS exists, then the deploy-generated worker in deploy.ts calls resolveStaticAssetSignal again on the already-resolved response. The second call is a no-op (header is gone), but it's wasteful and confusing — two layers both claim responsibility for the same signal. One of the two resolution sites should be removed.

Other comments:

  • The statusOverride logic in resolveStaticAssetSignal needs a comment explaining why middleware status is only applied when the asset fetch succeeds
  • The basePath 404 guard is a meaningful behavioral change beyond "serve public files" — should be called out in the PR description
  • Confirmed the GET/HEAD + .rsc exclusion + post-middleware cleanPathname usage are all correct for Next.js semantics
  • The prod-server signal handling has proper defense-in-depth with cancelResponseBody and fallback 404

github run

@hyoban
Copy link
Copy Markdown
Contributor Author

hyoban commented Apr 3, 2026

resolved

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix: serve public files in prod

The signal-based approach is well-designed: the RSC handler identifies public file requests via the __publicFiles Set, returns a lightweight signal response with the x-vinext-static-file header, and the appropriate server layer (Node.js prod-server or Cloudflare app-router-entry.ts) resolves the signal into an actual file serve. This keeps the RSC entry runtime-agnostic while each deployment target handles file I/O in its native way.

The previous review concerns (double resolution in deploy-generated workers, cache staleness, symlink handling) have all been addressed. CI is fully green.

A few minor observations below, none blocking.

// afterFiles/fallback rewrites, matching Next.js routing semantics.
if (
(request.method === "GET" || request.method === "HEAD") &&
!pathname.endsWith(".rsc") &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: pathname here (with .rsc suffix intact) vs cleanPathname (.rsc stripped) is a subtle distinction that's easy to misread. The logic is correct — pathname retains .rsc so the guard rejects RSC data fetches, while cleanPathname is the actual filesystem path to look up — but a brief inline comment would help future readers understand why two different variables are used in the same condition.

Suggested change
!pathname.endsWith(".rsc") &&
// Guard: pathname retains the .rsc suffix (if any) so we can reject RSC data fetches;
// cleanPathname has it stripped and is the actual filesystem path to match.
if (
(request.method === "GET" || request.method === "HEAD") &&
!pathname.endsWith(".rsc") &&
__publicFiles.has(cleanPathname)
) {

staticResponseHeaders,
response.status,
);
cancelResponseBody(response);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: cancelResponseBody(response) is called after tryServeStatic regardless of whether serving succeeded. Since the signal response has a null body, this is a no-op, but if the signal construction ever changes (e.g., someone adds a body for debugging), the cancel call at this position is correct and defensive. Good.

const request = nodeToWebRequest(req, normalizedUrl);
const response = await rscHandler(request);

const staticFileSignal = response.headers.get("x-vinext-static-file");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The x-vinext-static-file header name is used as a string literal in three separate files (app-rsc-entry.ts template, prod-server.ts, worker-utils.ts). If this protocol ever changes, it's easy to miss one. Consider extracting it into a shared constant (e.g., in worker-utils.ts) that both prod-server.ts and worker-utils.ts can import, and that the entry template can reference via JSON.stringify().

Not blocking — the current test coverage (deploy.test.ts, app-router.test.ts, serve-static.test.ts) would catch a mismatch.

const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn());

if (result instanceof Response) {
if (env?.ASSETS) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean approach — app-router-entry.ts is the single resolution point for the static asset signal on Cloudflare, and the deploy-generated worker (which wraps this entry) correctly delegates without its own resolution. The test at deploy.test.ts line 396-402 explicitly guards against the double-resolution regression. Well done.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review submitted with approval. Here's a summary of what I posted:

Verdict: Approved

The signal-based architecture is clean — the RSC handler identifies public file requests via a __publicFiles Set built at build time, returns a lightweight signal response (x-vinext-static-file header), and each server layer (Node.js prod-server.ts or Cloudflare app-router-entry.ts) resolves the signal into actual file serving. This keeps the RSC entry runtime-agnostic.

Previous review concerns (double resolution in deploy workers, cache staleness, symlink handling) were all addressed. All CI checks pass.

Minor suggestions (non-blocking):

  1. Add an inline comment in the RSC entry template clarifying why pathname (with .rsc) and cleanPathname (without .rsc) are both used in the public files guard — it's correct but subtle.
  2. Consider extracting the x-vinext-static-file header name into a shared constant since it's used as a string literal in three files.
  3. Noted the good defensive patterns: cancelResponseBody after static serving, the deploy-generated worker test guarding against double-resolution regression, and the statusOverride logic in resolveStaticAssetSignal.

github run

@james-elicx james-elicx merged commit 1dc2303 into cloudflare:main Apr 3, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants